Моя задача — провести оценку результатов A/B-теста. В распоряжении есть датасет с действиями пользователей, техническое задание и несколько вспомогательных датасетов.
Комментарий от тимлида №1
Цель так и не расписана. Хорошо, когда не просто копировать, а как-то анализировать задачи на вход и приводить понятные цель с точки зрения основной бизнес задачи.
import pandas as pd
import numpy as np
import seaborn as sns
import datetime as dt
import scipy.stats as st
import plotly.express as px
import math as mth
from plotly import graph_objects as go
import matplotlib.pyplot as plt
sns.set_style("darkgrid")
Комментарий от тимлида №1
Хорошо, модули загружены
#функция расчета z-критерия для двух групп
def z_test(successes_1, successes_2, trials_1, trials_2, alpha=0.05, bonferroni_alpha = 1):
alpha = alpha / bonferroni_alpha # критический уровень статистической значимости
successes = np.array([successes_1,successes_2])
trials = np.array([trials_1, trials_2])
# пропорция успехов в первой группе:
p1 = successes[0]/trials[0]
# пропорция успехов во второй группе:
p2 = successes[1]/trials[1]
print(successes[0], successes[1],trials[0] , trials[1])
# пропорция успехов в комбинированном датасете:
p_combined = (successes[0] + successes[1]) / (trials[0] + trials[1])
# разница пропорций в датасетах
difference = p1 - p2
# считаем статистику в ст.отклонениях стандартного нормального распределения
z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/trials[0] + 1/trials[1]))
# задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
distr = st.norm(0, 1)
p_value = (1 - distr.cdf(abs(z_value))) * 2
print('p-значение: ', p_value)
print('значение alpha:', alpha)
if p_value < alpha:
print('Отвергаем нулевую гипотезу: между долями есть значимая разница')
else:
print(
'Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными'
)
Комментарий от тимлида №1
Здорово, что ты знаешь о такого рода поправках и успешно используешь в своем проекте!
Также можно отметить, что с уменьшением уровня значимости, но уменьшается и мощность теста, тем самым увеличивается вероятность ошибки второго рода. Такой вот компромисс
Это довольно распространенная тема, поэтому рекомендую ознакомиться более подробно - https://youtu.be/qbbY7ubzDoE.
ab_project_marketing_events = pd.read_csv('https://code.s3.yandex.net//datasets/ab_project_marketing_events.csv')
final_ab_new_users = pd.read_csv('https://code.s3.yandex.net//datasets/final_ab_new_users.csv')
final_ab_events = pd.read_csv('https://code.s3.yandex.net//datasets/final_ab_events.csv')
final_ab_participants = pd.read_csv('https://code.s3.yandex.net//datasets/final_ab_participants.csv')
Комментарий от тимлида №1
Для подгрузки данных можно использовать конструкцию try-except, она поможет избежать потенциальных ошибок при загрузке данных, связанных, например, с некорректным указанием путей.
Подробнее о конструкции по ссылке:
Либо же можно использовать стандартную библиотеку os:
https://pythonworld.ru/moduli/modul-os.html
Несколько интересных статей кейсы использования конструкции:
https://www.programiz.com/python-programming/exception-handling
https://towardsdatascience.com/do-not-abuse-try-except-in-python-d9b8ee59e23b
https://www.techbeamers.com/use-try-except-python/
Как вариант в try можно указать корректные пути (в нашем случае глобальные) в except - некорректные (локальные). Можно также специфицровать тип ошибки, FileNotFoundError или задать кастомный тип ошибки (FilePathError, например)
Она полезна, если ты работаешь локально, а потом подгружаешь проект на платформу. Конструкция позволит не падать коду и локально, и на сервере ЯП, так как если не сработает один блок с путями, сработает другой.
Ну и вообще, в целом полезно про эту констуркцию знать, она универсальна и может быть использована в разных задачах.
ab_project_marketing_events.head(3)
| name | regions | start_dt | finish_dt | |
|---|---|---|---|---|
| 0 | Christmas&New Year Promo | EU, N.America | 2020-12-25 | 2021-01-03 |
| 1 | St. Valentine's Day Giveaway | EU, CIS, APAC, N.America | 2020-02-14 | 2020-02-16 |
| 2 | St. Patric's Day Promo | EU, N.America | 2020-03-17 | 2020-03-19 |
final_ab_new_users.head(3)
| user_id | first_date | region | device | |
|---|---|---|---|---|
| 0 | D72A72121175D8BE | 2020-12-07 | EU | PC |
| 1 | F1C668619DFE6E65 | 2020-12-07 | N.America | Android |
| 2 | 2E1BF1D4C37EA01F | 2020-12-07 | EU | PC |
final_ab_events.head(3)
| user_id | event_dt | event_name | details | |
|---|---|---|---|---|
| 0 | E1BDDCE0DAFA2679 | 2020-12-07 20:22:03 | purchase | 99.99 |
| 1 | 7B6452F081F49504 | 2020-12-07 09:22:53 | purchase | 9.99 |
| 2 | 9CD9F34546DF254C | 2020-12-07 12:59:29 | purchase | 4.99 |
final_ab_participants.head(3)
| user_id | group | ab_test | |
|---|---|---|---|
| 0 | D1ABA3E2887B6A73 | A | recommender_system_test |
| 1 | A7A3664BD6242119 | A | recommender_system_test |
| 2 | DABC14FDDFADD29E | A | recommender_system_test |
dataset = [ab_project_marketing_events, final_ab_new_users, final_ab_events, final_ab_participants]
sp = ['ab_project_marketing_events', 'final_ab_new_users', 'final_ab_events', 'final_ab_participants']
sp
['ab_project_marketing_events', 'final_ab_new_users', 'final_ab_events', 'final_ab_participants']
for i, t in zip(sp, dataset):
print(i)
print()
print(t.info())
print()
print(t.isna().agg(['sum', 'mean']))
print()
print(f"Дубликатов: {t.duplicated().sum()}")
print()
print('-----------------------')
ab_project_marketing_events
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 14 entries, 0 to 13
Data columns (total 4 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 name 14 non-null object
1 regions 14 non-null object
2 start_dt 14 non-null object
3 finish_dt 14 non-null object
dtypes: object(4)
memory usage: 576.0+ bytes
None
name regions start_dt finish_dt
sum 0.0 0.0 0.0 0.0
mean 0.0 0.0 0.0 0.0
Дубликатов: 0
-----------------------
final_ab_new_users
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 61733 entries, 0 to 61732
Data columns (total 4 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 user_id 61733 non-null object
1 first_date 61733 non-null object
2 region 61733 non-null object
3 device 61733 non-null object
dtypes: object(4)
memory usage: 1.9+ MB
None
user_id first_date region device
sum 0.0 0.0 0.0 0.0
mean 0.0 0.0 0.0 0.0
Дубликатов: 0
-----------------------
final_ab_events
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 440317 entries, 0 to 440316
Data columns (total 4 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 user_id 440317 non-null object
1 event_dt 440317 non-null object
2 event_name 440317 non-null object
3 details 62740 non-null float64
dtypes: float64(1), object(3)
memory usage: 13.4+ MB
None
user_id event_dt event_name details
sum 0.0 0.0 0.0 377577.000000
mean 0.0 0.0 0.0 0.857512
Дубликатов: 0
-----------------------
final_ab_participants
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 18268 entries, 0 to 18267
Data columns (total 3 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 user_id 18268 non-null object
1 group 18268 non-null object
2 ab_test 18268 non-null object
dtypes: object(3)
memory usage: 428.3+ KB
None
user_id group ab_test
sum 0.0 0.0 0.0
mean 0.0 0.0 0.0
Дубликатов: 0
-----------------------
Пропуски есть только final_ab_events['details'], посмотрим на них
final_ab_events[final_ab_events['details'].isna()].iloc[:5]
| user_id | event_dt | event_name | details | |
|---|---|---|---|---|
| 62740 | 2E1BF1D4C37EA01F | 2020-12-07 09:05:47 | product_cart | NaN |
| 62741 | 50734A22C0C63768 | 2020-12-07 13:24:03 | product_cart | NaN |
| 62742 | 5EB159DA9DC94DBA | 2020-12-07 22:54:02 | product_cart | NaN |
| 62743 | 084A22B980BA8169 | 2020-12-07 15:25:55 | product_cart | NaN |
| 62744 | 0FC21E6F8FAA8DEC | 2020-12-07 06:56:27 | product_cart | NaN |
Посмотрим какие вообще есть события в event_name
final_ab_events['event_name'].value_counts(ascending=False)
login 189552 product_page 125563 purchase 62740 product_cart 62462 Name: event_name, dtype: int64
Проверим что при событии purchase нет пропусков
final_ab_events[(final_ab_events['details'].isna()) & (final_ab_events['event_name'] == 'purchase')]
| user_id | event_dt | event_name | details |
|---|
Из всех событий вычтем покупки и должны получить все пропуски
(len(final_ab_events) - len(final_ab_events[final_ab_events['event_name'] == 'purchase'])
== final_ab_events['details'].isna().sum())
True
for i, t in zip(sp, dataset):
print(i)
print()
for index in t.columns:
row = t[index].nunique()
print(f'Уникальных значений {index}: {row}')
print('-----------------')
ab_project_marketing_events Уникальных значений name: 14 Уникальных значений regions: 6 Уникальных значений start_dt: 14 Уникальных значений finish_dt: 14 ----------------- final_ab_new_users Уникальных значений user_id: 61733 Уникальных значений first_date: 17 Уникальных значений region: 4 Уникальных значений device: 4 ----------------- final_ab_events Уникальных значений user_id: 58703 Уникальных значений event_dt: 267268 Уникальных значений event_name: 4 Уникальных значений details: 4 ----------------- final_ab_participants Уникальных значений user_id: 16666 Уникальных значений group: 2 Уникальных значений ab_test: 2 -----------------
Комментарий от тимлида №1
Совет на будущее, смотри, не есть хорошо, когда налево и направо пытаться заполнить пропуски. В реальной работе - сейчас заменил на ноль, а через месяц забыл и среднее подсчитал. Плюс могут быть моменты, когда покупка бонусная или подарок, будет ноль стоить. В общем, если можно оставить пропуски пропусками - а тут это можно сделать, то лучше так и сделать, на результаты, даже если мы что-то захотим подсчитать - это не повлияет
recommender_system_test;product_page,product_cart,purchase.ab_project_marketing_events — календарь маркетинговых событий на 2020 год.
Структура файла:
name — название маркетингового события;regions — регионы, в которых будет проводиться рекламная кампания;start_dt — дата начала кампании;finish_dt — дата завершения кампании.final_ab_new_users — пользователи, зарегистрировавшиеся с 7 по 21 декабря 2020 года.
Структура файла:
user_id — идентификатор пользователя;first_date — дата регистрации;region — регион пользователя;device — устройство, с которого происходила регистрация.final_ab_events — действия новых пользователей в период с 7 декабря 2020 по 4 января 2021 года.
Структура файла:
user_id — идентификатор пользователя;event_dt — дата и время события;event_name — тип события;details — дополнительные данные о событии. Например, для покупок, purchase, в этом поле хранится стоимость покупки в долларах.final_ab_participants — таблица участников тестов.
Структура файла:
user_id — идентификатор пользователя;ab_test — название теста;group — группа пользователя.print(f" Минимальная дата старта рекламной компании: {ab_project_marketing_events['start_dt'].min()}")
print(f" Максимальная дата старта рекламной компании: {ab_project_marketing_events['start_dt'].max()}")
Минимальная дата старта рекламной компании: 2020-01-25 Максимальная дата старта рекламной компании: 2020-12-30
print(f" Минимальная дата завершения рекламной компании: {ab_project_marketing_events['finish_dt'].min()}")
print(f" Максимальная дата завершения рекламной компании: {ab_project_marketing_events['finish_dt'].max()}")
Минимальная дата завершения рекламной компании: 2020-02-07 Максимальная дата завершения рекламной компании: 2021-01-07
print(f" Минимальная дата регистрации: {final_ab_new_users['first_date'].min()}")
print(f" Максимальная дата регистрации: {final_ab_new_users['first_date'].max()}")
Минимальная дата регистрации: 2020-12-07 Максимальная дата регистрации: 2020-12-23
Зацепили два дня лишних, позже удалим их.
print(f" Минимальная дата события: {final_ab_events['event_dt'].min()}")
print(f" Максимальная дата события: {final_ab_events['event_dt'].max()}")
Минимальная дата события: 2020-12-07 00:00:33 Максимальная дата события: 2020-12-30 23:36:33
По ТЗ мы должны были собирать данные новых пользователей по 04/01, по факту события прекратились 30/12.
Посмотрим распределение событий по дате
Комментарий от тимлида №1
Да, тест остановился на 5 дней раньше
# подготовим таблицу
t = final_ab_events
t['event_dt'] = pd.to_datetime(t['event_dt']).dt.date
# строим график
plt.figure(figsize=(15,5))
g = sns.histplot(x='event_dt', data=t,)
g.set_xlabel('Дата',fontsize=12)
g.set_ylabel('Количество событий ', fontsize=12)
g.set_title('Распределение собитий по датам', fontsize=15)
plt.show()
Странно что действия прекратились 30/12 возможно или раньше остановили тест или был сбой
Посмотрим из каких регионов есть пользователи
final_ab_new_users['region'].value_counts(normalize=True, ascending=False)
EU 0.749518 N.America 0.148300 CIS 0.051107 APAC 0.051075 Name: region, dtype: float64
Вывод:
У нас есть 4 датафрейма, дубликатов нет, в одном есть пропуски, пропуски появились в столбце details для событий не связанных с оплатой. Сбор данных прекратили 30/12, по ТЗ тест должен был идти до 04/01.
Для дальнейшего исследования нам надо дату привести к формату дата и удалим пользователей зарегистрировавшихся после 21/12/2020
Комментарий от тимлида №1
По поводу 23 числа. Выше ты смотришь на все логи, они могут хоть за 10 лет быть. Проверять на ТЗ мы должны только данные, которые относятся к нашему тесту.. В данном случае, у тебя получается, что набор новых пользователей был осуществлён 23 числа и мы можем грешить на инженеров. Но давай посмотрим именно дату остановки набора именно нашего теста? Для этого объединим таблицы participants с нужным тестом и new_users. И посмотрим на окончание теста? Соответствует ли оно заданию?
Выше я ни кого не удалял, а просто изучал общую информацию.... Удалил я их в 33 аутпуте, но удалил из таблицы final_ab_new_users тк они противоречат описанию таблицы, проверил в 38 аутпуте что остались только те, которые нам надо
Комментарий от тимлида №2
Алексей, рад тебя видеть)
Ранее описал правильно, возможно не туда поставил комментарий. В 34 ячейке ты удалял пользователей? Но в этом нет смысла, т.к. для нашего теста инженеры корректно остановили набор новых пользоваталей (в 38 ячейке ты это вывел)
Дату приведем в к формату дата
ab_project_marketing_events['start_dt'] = pd.to_datetime(ab_project_marketing_events['start_dt'])
ab_project_marketing_events['finish_dt'] = pd.to_datetime(ab_project_marketing_events['finish_dt'])
final_ab_new_users['first_date'] = pd.to_datetime(final_ab_new_users['first_date'])
final_ab_events['event_dt'] = pd.to_datetime(final_ab_events['event_dt'])
df = final_ab_participants[final_ab_participants['ab_test'] == 'recommender_system_test']
len(df)
6701
Отфильтруем по группе и проверим что массивы с ID не пересекаются
np.intersect1d(df.query('group == "B"')['user_id'],
df.query('group == "A"')['user_id'])
array([], dtype=object)
Пользователи не пересекаются.
Комментарий от тимлида №2
Да, внутри теста всё хорошо)
final_ab_participants['ab_test'].value_counts(normalize=True)
interface_eu_test 0.633184 recommender_system_test 0.366816 Name: ab_test, dtype: float64
Помимо нашего теста, пользователи принимали участие еще в другом тесте, давайте посомтрим пересекают ли они
len(np.intersect1d(final_ab_participants.query('ab_test == "recommender_system_test"')['user_id'],
final_ab_participants.query('ab_test == "interface_eu_test"')['user_id']))
1602
1602 пользователя принимали участи в обоих тестах.
Проверим сколько было в группах A
len(np.intersect1d(final_ab_participants.query('ab_test == "recommender_system_test" and group == "A"')['user_id'],
final_ab_participants.query('ab_test == "interface_eu_test" and group == "A"')['user_id']))
482
Проверим сколько было в группах B
len(np.intersect1d(final_ab_participants.query('ab_test == "recommender_system_test" and group == "B"')['user_id'],
final_ab_participants.query('ab_test == "interface_eu_test" and group == "B"')['user_id']))
344
В recommender_system_test в А и interface_eu_test B
len(np.intersect1d(final_ab_participants.query('ab_test == "recommender_system_test" and group == "A"')['user_id'],
final_ab_participants.query('ab_test == "interface_eu_test" and group == "B"')['user_id']))
439
В recommender_system_test в B и interface_eu_test A
len(np.intersect1d(final_ab_participants.query('ab_test == "recommender_system_test" and group == "B"')['user_id'],
final_ab_participants.query('ab_test == "interface_eu_test" and group == "A"')['user_id']))
337
Можем предположить, что пересечение пользователей никак не скажутся на нашем эксперименте.
Посмотрим проходили ли еше маркетинговые события в исследуемый нами период.
Комментарий от тимлида №1
Верное наблюдение.
В данной ситуации, наиболее корректным решением будет проверить в какие именно группы теста interface_eu_test попали пользователи теста recommender_system_test. Ведь если они попали только в контрольную группу - это значит, что тест interface_eu_test никак на них не повлиял
ab_project_marketing_events[(ab_project_marketing_events['start_dt'] >= '2020-12-07')
& ((ab_project_marketing_events['start_dt'] <= '2021-01-04'))]
| name | regions | start_dt | finish_dt | |
|---|---|---|---|---|
| 0 | Christmas&New Year Promo | EU, N.America | 2020-12-25 | 2021-01-03 |
| 10 | CIS New Year Gift Lottery | CIS | 2020-12-30 | 2021-01-07 |
В это время проходило два маркетинговых события, если одно началось 30/12 ( когда мы остановили сбор данных), то второе пришлось на наши даты, оно могло оказать влияния на поведение наших пользователей, но в равно доле.
Комментарий от тимлида №1
События, праздники и тп - влияют на две группы одинаково. Это просто изменение качества пользователей, но относительную конверсию группы В к группе А - не будет иметь воздействие. Но это нужно учитывать при изучении абсолютных данных.
# Удалим пользователей которые зарегистрировались после `2020-12-21`
#t = len(final_ab_new_users)
#final_ab_new_users = final_ab_new_users[final_ab_new_users['first_date'] <= '2020-12-21'].copy()
#print( f" Удалилили: {t - len(final_ab_new_users)} пользователей")
Объеденим df и final_ab_new_users
df = df.merge(final_ab_new_users, on='user_id', how='left').copy()
df.head(3)
| user_id | group | ab_test | first_date | region | device | |
|---|---|---|---|---|---|---|
| 0 | D1ABA3E2887B6A73 | A | recommender_system_test | 2020-12-07 | EU | PC |
| 1 | A7A3664BD6242119 | A | recommender_system_test | 2020-12-20 | EU | iPhone |
| 2 | DABC14FDDFADD29E | A | recommender_system_test | 2020-12-08 | EU | Mac |
print(f"Пропусков : {df['first_date'].isna().sum()}")
Пропусков : 0
Посмотрим есть ли пользователи которые зарегистрировались после 2020-12-21
df['first_date'].max()
Timestamp('2020-12-21 00:00:00')
После объединения таблиц, остались только те пользователи, которые зарегистрировались до 21/12/2020
Посмотрим на распределение по регионам, по ТЗ должно быть 15% новых пользователей из региона EU
df['region'].value_counts().to_frame()
| region | |
|---|---|
| EU | 6351 |
| N.America | 223 |
| APAC | 72 |
| CIS | 55 |
(df['region'].value_counts().to_frame()[:1]
/ final_ab_new_users['region'].value_counts().to_frame()[:1]) * 100
| region | |
|---|---|
| EU | 13.725956 |
Получилось меньше, чем планировали в ТЗ
# Как раз 15 процентов.
Комментарий от тимлида №1
Ты верно посчитал этот пункт
Комментарий от тимлида №2
Не стану краснить. По коду у тебя был до этого верный подход, но по логике - нет. (нам нужно теперь из знаменателя обрезать до 21 числа включительно по ТЗ, и мы получим ровно 15%). А числитель у нас и так хороший изначально
print(f"У нас пользователи зарегистрировавшиеся в течении:{(df['first_date'].max() - df['first_date'].min()).days} дней")
У нас пользователи зарегистрировавшиеся в течении:14 дней
Посомтрим на распределение пользователей по группам
group = df['group'].value_counts(normalize=True).to_frame()
group
| group | |
|---|---|
| A | 0.570661 |
| B | 0.429339 |
В группу А попало больше пользователей
Комментарий от тимлида №1
Равный размер групп дает оптимальную длительность теста, но, вообще, группы просто должны быть достаточно большими и не обязательно равными.
Вот здесь подробно расписано, что не все так страшно в несбалансированных выборках
Комментарий от тимлида №1
Алексей, также имеется несколько моментов, на которые стоит обратить внимание на этапе оценки корректности сбора данных:
Вывод:
В тесте приняло участие 6701 человек, часть из них принимало участие в другом тесте с интерфейсом, но были там распределены более менее равномерно (350-450 чел) по 4 корзинам, делать так плохо, но думаю этого количества достаточно, что б считать, что это не скажется на результате нашего эксперимента. Так же в это время проходило маркетинговое событие, которое могло оказать влияние на тест.
Ровно 15% новых пользователей из региона EU, как и просили в ТЗ.
Пользователи в группе распределены не равномерно, но в достаточно количестве.
Комментарий от тимлида №2
Оценка корректности сбора данных проведена отлично
Проверим как распределены количество событий на пользователя
df = df.merge(final_ab_events, on='user_id', how='left').copy()
df.head(3)
| user_id | group | ab_test | first_date | region | device | event_dt | event_name | details | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | D1ABA3E2887B6A73 | A | recommender_system_test | 2020-12-07 | EU | PC | 2020-12-07 | purchase | 99.99 |
| 1 | D1ABA3E2887B6A73 | A | recommender_system_test | 2020-12-07 | EU | PC | 2020-12-25 | purchase | 4.99 |
| 2 | D1ABA3E2887B6A73 | A | recommender_system_test | 2020-12-07 | EU | PC | 2020-12-07 | product_cart | NaN |
len(df)
27724
В ТЗ за 14 дней с момента регистрации пользователи покажут улучшение каждой метрики не менее, чем на 10%.
Давайте отберем этих пользователей
df = df[(df['event_dt'] - df['first_date']).dt.days <= 14]
len(df)
24070
Комментарий от тимлида №1
Корректная реализация. Молодец!
t = df.groupby('group').agg({'event_name':'count', 'user_id':'nunique'}).reset_index()
t['quantity_per_user'] = t['event_name'] / t['user_id']
t
| group | event_name | user_id | quantity_per_user | |
|---|---|---|---|---|
| 0 | A | 18947 | 2747 | 6.897343 |
| 1 | B | 5123 | 928 | 5.520474 |
Пользователи в группе А совершают больше действий
Комментарий от тимлида №2
Здорово, что сравнил среднее значение по двум группам. В идеале можно провести ещё стат тест
plt.figure(figsize=(15,5))
ax = sns.barplot(data = t, x='group', y='event_name', palette='Paired')
ax.set_xlabel('',fontsize=12)
ax.set_ylabel('Количество событий', fontsize=12)
ax.set_title('Распределение количества событий', fontsize=15)
plt.xticks(rotation = 25)
plt.show()
После того, как мы установили лайфтайм, дисбаланс классов усилился.
Раньше было в процентах
group
| group | |
|---|---|
| A | 0.570661 |
| B | 0.429339 |
Стало
np.round(t.iloc[0:2, 2] / t['user_id'].sum() * 100, 2).to_frame()
| user_id | |
|---|---|
| 0 | 74.75 |
| 1 | 25.25 |
Посмортим на распредеение событий
plt.figure(figsize=(15,5))
g = sns.histplot(x='event_dt', data=df, hue='group')
g.set_xlabel('Дата собятия',fontsize=12)
g.set_ylabel('Количество событий ', fontsize=12)
g.set_title('Распределение событий', fontsize=15)
plt.xticks(rotation=45)
plt.show()
Группа А совершает больше событий, но там и больше выборка.
Посомтрим на отношение групп по дням
Комментарий от тимлида №1
С чем может быть связан скачек событий в группе А с 14 числа? (можно рассмотреть динамику набора пользоваталей)
Комментарий от тимлида №2
Если честно, то я так и не увидел реализацию динамики набора новых пользователей. 14 - день когда активные пользователи перестали набираться в группе А
relation = df.pivot_table(index='event_dt', values='event_name', columns='group', aggfunc='count' ).reset_index()
relation['event_dt'] = relation['event_dt'].astype('str')
relation['percent'] = (relation['A'] / relation['B'])
relation.iloc[:5]
| group | event_dt | A | B | percent |
|---|---|---|---|---|
| 0 | 2020-12-07 | 331 | 378 | 0.875661 |
| 1 | 2020-12-08 | 341 | 252 | 1.353175 |
| 2 | 2020-12-09 | 385 | 361 | 1.066482 |
| 3 | 2020-12-10 | 350 | 263 | 1.330798 |
| 4 | 2020-12-11 | 374 | 168 | 2.226190 |
f, ax = plt.subplots(figsize=(15, 5), dpi= 250)
sns.barplot(data=relation, x=relation['event_dt'], y= relation['percent'], color='steelblue')
plt.title( 'Отношение событий между группами', fontsize=15)
plt.xlabel('дата события', fontsize=12),
plt.ylabel('Доля', fontsize=12)
plt.xticks(rotation=90)
plt.show()
Если первые 4 дня конверсия была одинаковой, то позже группа А стала показывать больше событий.
У меня на графике нет 30 декабря, странно, давайте посмотрим события в этот день
df[df['event_dt'] == '2020-12-30']
| user_id | group | ab_test | first_date | region | device | event_dt | event_name | details |
|---|
Событий нет, скорее всего 30/12 что то произошло и мы перестали фиксировать события.
Посмотрим на общее количество событий и количество событий по категориям
# подготовим таблицу
group_a = df[df['group'] == 'A'].pivot_table(index=['event_name','event_dt'], values='group', aggfunc='count').reset_index()
# строим график
fig = px.bar(group_a, x='event_dt', y='group',
width = 950, height = 550, color='event_name', text='group', opacity = 0.8)
fig.update_xaxes(tickangle=30)
fig.update_layout(
title='Общее количество событий и количество событий по категориям группа А',
xaxis_title= '',
yaxis_title='Количество событий')
fig.update_traces( textfont_size = 12 , textangle = 0, textposition = "outside" , cliponaxis = False )
fig.show()
# подготовим таблицу
group_b = df[df['group'] == 'B'].pivot_table(index=['event_name','event_dt'], values='group', aggfunc='count').reset_index()
# строим график
fig = px.bar(group_b, x='event_dt', y='group',
width = 950, height = 550, color='event_name', text='group', opacity = 0.8)
fig.update_xaxes(tickangle=30)
fig.update_layout(
title='Общее количество событий и количество событий по категориям группа B',
xaxis_title= '',
yaxis_title='Количество событий')
fig.update_traces( textfont_size = 12 , textangle = 0, textposition = "outside" , cliponaxis = False )
fig.show()
Это было видно и ранее, но я только сейчас обратил внимание, что в группе В активность выше в первые дни компании, поэтому и на графике отношения событий показатели и были в районе 1 а в группе А на вторую неделю начался рост.
С 21/12 в обоих группах пошел спад.
Посмотрим сколько пользователей приходили каждый 5 дней в наши группы
test_1 = df[df['first_date'] <= '2020-12-11']
test_1.groupby('group').agg({'event_name':'count', 'user_id':'nunique'}).reset_index()
| group | event_name | user_id | |
|---|---|---|---|
| 0 | A | 3004 | 501 |
| 1 | B | 2174 | 365 |
test = df[(df['first_date'] >= '2020-12-12') & (df['first_date'] < '2020-12-17')]
test.groupby('group').agg({'event_name':'count', 'user_id':'nunique'}).reset_index()
| group | event_name | user_id | |
|---|---|---|---|
| 0 | A | 6508 | 884 |
| 1 | B | 1547 | 271 |
test = df[(df['first_date'] >= '2020-12-17') & (df['first_date'] <= '2020-12-21')]
test.groupby('group').agg({'event_name':'count', 'user_id':'nunique'}).reset_index()
| group | event_name | user_id | |
|---|---|---|---|
| 0 | A | 9435 | 1362 |
| 1 | B | 1402 | 292 |
Из таблиц видно, что с течением времени сплитование стало больше отправлять в группу А, из-за этого и произошли видимые изменения на графике.
# подготовим таблицу
test_A = test_1[test_1['group'] == 'A'].pivot_table(index=['event_name','event_dt'], values='group', aggfunc='count').reset_index()
# строим график
fig = px.bar(test_A, x='event_dt', y='group',
width = 950, height = 550, color='event_name', text='group', opacity = 0.8)
fig.update_xaxes(tickangle=30)
fig.update_layout(
title='Общее количество событий и количество событий по категориям группа А',
xaxis_title= '',
yaxis_title='Количество событий')
fig.update_traces( textfont_size = 12 , textangle = 0, textposition = "outside" , cliponaxis = False )
fig.show()
# подготовим таблицу
test_B = test_1[test_1['group'] == 'B'].pivot_table(index=['event_name','event_dt'], values='group', aggfunc='count').reset_index()
# строим график
fig = px.bar(test_B, x='event_dt', y='group',
width = 950, height = 550, color='event_name', text='group', opacity = 0.8)
fig.update_xaxes(tickangle=30)
fig.update_layout(
title='Общее количество событий и количество событий по категориям группа B',
xaxis_title= '',
yaxis_title='Количество событий')
fig.update_traces( textfont_size = 12 , textangle = 0, textposition = "outside" , cliponaxis = False )
fig.show()
Мы отобрали пользователей, которые пришли к нам до 11/12, можно заметить, что поведение у них схоже.
Так же можем увидеть, что как правило пользователь приходит на сайт и в первые дни совершает все этапы воронки.
Посмотрим как регистрировались пользователи в эти 5 дней
sts = df[(df['first_date'] <= '2020-12-11') & (df['event_name'] == 'login')]['first_date']
sts = pd.to_datetime(sts).dt.day
sts.hist(bins=10, color='steelblue',figsize=(15, 5), ec="darkgrey")
plt.title('Распределение регситраций в первые 5 дней', fontsize=15)
plt.xlabel('', fontsize=12)
plt.ylabel('количество регистраций', fontsize=10)
plt.xticks(rotation = 90)
plt.show()
funnel_a = df[df['group'] == "A"].groupby('event_name').agg(count=('user_id','nunique')).reset_index()
funnel_a.rename(index={0:0, 1:2, 2:1, 3:3}, inplace= True )
funnel_a = funnel_a.sort_index()
funnel_a
| event_name | count | |
|---|---|---|
| 0 | login | 2747 |
| 1 | product_page | 1780 |
| 2 | product_cart | 824 |
| 3 | purchase | 872 |
funnel_b = df[df['group'] == "B"].groupby('event_name').agg(count=('user_id','nunique')).reset_index()
funnel_b.rename(index={0:0, 1:2, 2:1, 3:3}, inplace= True )
funnel_b = funnel_b.sort_index()
funnel_b
| event_name | count | |
|---|---|---|
| 0 | login | 927 |
| 1 | product_page | 523 |
| 2 | product_cart | 255 |
| 3 | purchase | 256 |
fig = go.Figure()
fig.add_trace(go.Funnel(name = 'A',
y = funnel_a['event_name'],
x = funnel_a['count'],
opacity = 0.9,
textposition = 'inside',
textinfo = 'value + percent previous + percent initial'))
fig.add_trace(go.Funnel(name = 'B',
y = funnel_b['event_name'],
x = funnel_b['count'],
opacity = 0.9,
textposition = 'auto',
textinfo = 'value + percent previous + percent initial'))
fig.update_layout(title_text='Воронка событий по группам')
fig.show()
Поведение пользователей в группе А выглядит лучше, больший процент пользователей совершает покупку.
Часть пользователей делает покупку пропуская страницу корзины
Комментарий от тимлида №1
Воронка построена корректно и здорово, что заметил данный нюанс. Это может говорить о том, что на платформе нестрогая воронка продаж и можно приобрести продукт минуя некоторые этапы
Ранее в ходе исследования, мы отметили адекватное распределение по группам в первые 5 дней теста, давайте построим воронку для этих пользователей
cut = df[df['first_date'] <= '2020-12-11']
funnel_at = cut[cut['group'] == "A"].groupby('event_name').agg(count=('user_id','nunique')).reset_index()
funnel_at.rename(index={0:0, 1:2, 2:1, 3:3}, inplace= True )
funnel_at = funnel_at.sort_index()
funnel_bt = cut[cut['group'] == "B"].groupby('event_name').agg(count=('user_id','nunique')).reset_index()
funnel_bt.rename(index={0:0, 1:2, 2:1, 3:3}, inplace= True )
funnel_bt = funnel_bt.sort_index()
fig = go.Figure()
fig.add_trace(go.Funnel(name = 'A',
y = funnel_at['event_name'],
x = funnel_at['count'],
opacity = 0.9,
textposition = 'inside',
textinfo = 'value + percent previous + percent initial'))
fig.add_trace(go.Funnel(name = 'B',
y = funnel_bt['event_name'],
x = funnel_bt['count'],
opacity = 0.9,
textposition = 'auto',
textinfo = 'value + percent previous + percent initial'))
fig.update_layout(title_text='Воронка событий по группам в первые 5 дней теста')
fig.show()
Если на общей воронке группа А выглядела лучше, то тут показатели схожи, группы ведут себя одинаково.
Говорить, что группа В покажет улучшение каждой метрики не менее, чем на 10% не представляется возможным.
Возможно если б сплиование работала корректно, общая воронка выглядела бы по другому
Комментарий от тимлида №1
Не увидел решения по
Давай также сравним среднее кол-во события на пользователя по группам. В идеале можно провести ещё стат тест.
Комментарий от тимлида №2
Спасибо, что подметил)
df[df['group'] == 'A']['event_name'].count()
18947
Вывод:
По ТЗ мы должны посмотреть поведение новых пользователей за 14 дней с момента регистрации, по факту в выборку попали пользователи которые прожили в приложении больше дней.
Убрав их мы увидели большой дисбаланс групп, в группе А в три раза больше пользователей.
Мы предположили, что сбой мог произойти на 5 -6 день сплитования, так как до этого распределение смотрелось более менее адекватно. Так же тест проводился до 04/01 , но по факту сбор данных прекратился 29/12, за 30 декабря уже нет ни каких данных.
Если смотреть общую воронку по группам, тио группа А выглядит предпочтительно, при этом если посмотреть воронку за первые 5 дней, группы смотрятся одинаково.
Так же на общую группу могло оказать влияние маркетинговое событие которое старануло 25/12.
В то время как не выборку из первых 5 дней влияние оно оказать не должно было…
Но 5 дней противоречит нашему ТЗ, мы не можем на него равняться, но можем утверждать, что результаты общего теста не объективны.
df.groupby('group').agg(count=('user_id','nunique')).reset_index()
| group | count | |
|---|---|---|
| 0 | A | 2747 |
| 1 | B | 928 |
В исследовании у меня было 927 в группе В, тут стало +1, пропусков и дубликатов нет. Найдем его
q = df[(df['group'] == "B") & (df['event_name'] == 'login')]['user_id'].unique()
w = df[(df['group'] == "B")]['user_id'].unique()
np.setdiff1d(w, q)
array(['5FF8B6AB257B404F'], dtype=object)
df[df['user_id'] == '5FF8B6AB257B404F']
| user_id | group | ab_test | first_date | region | device | event_dt | event_name | details | |
|---|---|---|---|---|---|---|---|---|---|
| 8523 | 5FF8B6AB257B404F | B | recommender_system_test | 2020-12-07 | EU | Android | 2020-12-07 | purchase | 4.99 |
Посмотрим на все действия от первой таблицы
final_ab_events[final_ab_events['user_id'] == '5FF8B6AB257B404F']
| user_id | event_dt | event_name | details | |
|---|---|---|---|---|
| 1411 | 5FF8B6AB257B404F | 2020-12-07 | purchase | 4.99 |
| 53685 | 5FF8B6AB257B404F | 2020-12-25 | purchase | 4.99 |
| 412914 | 5FF8B6AB257B404F | 2020-12-25 | login | NaN |
Странный клиент, хорошо, что единственный, не буду его учитывать
Сформируем таблицы
a_b_events_by_users = df.pivot_table(index='event_name', columns='group', values='user_id', aggfunc='nunique').reset_index()
a_b_events_by_users.rename(index={0:0, 1:2, 2:1, 3:3}, inplace= True )
a_b_events_by_users = a_b_events_by_users.sort_index()
a_b_events_by_users = a_b_events_by_users = a_b_events_by_users.iloc[1:4]
a_b_events_by_users
| group | event_name | A | B |
|---|---|---|---|
| 1 | product_page | 1780 | 523 |
| 2 | product_cart | 824 | 255 |
| 3 | purchase | 872 | 256 |
a_b_group = df[df['event_name'] == 'login'].groupby('group')['user_id'].nunique()
a_b_group
group A 2747 B 927 Name: user_id, dtype: int64
Проверим статистическую разницу долей z-критерием.. Сформулируем гипотезы
Сформулируем гипотезы.
Нулевая: Нет статистически значимого различия, нет оснований считать доли разными.
Альтернативная: Между долями есть значимая разница, отвергаем нулевую гипотезу.
Критический уровень статистической значимости укажем 0,05
Тк у нас сразу три теста, применим поправку Бонферрони для минимизации рисков
Комментарий от тимлида №1
Верная интерпретация нулевой и альтернативной гипотез
for i in a_b_events_by_users['event_name'].unique():
print(f'Статистически значимые различая между группами A и B для события {i}')
z_test(a_b_events_by_users.loc[a_b_events_by_users['event_name'] == i, 'A'],
a_b_events_by_users.loc[a_b_events_by_users['event_name'] == i, 'B'],
trials_1 = a_b_group.loc['A'],
trials_2 = a_b_group.loc['B'],
bonferroni_alpha = 3)
print()
print('---')
print()
Статистически значимые различая между группами A и B для события product_page [1780] [523] 2747 927 p-значение: [5.08436808e-06] значение alpha: 0.016666666666666666 Отвергаем нулевую гипотезу: между долями есть значимая разница --- Статистически значимые различая между группами A и B для события product_cart [824] [255] 2747 927 p-значение: [0.15034216] значение alpha: 0.016666666666666666 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными --- Статистически значимые различая между группами A и B для события purchase [872] [256] 2747 927 p-значение: [0.01847463] значение alpha: 0.016666666666666666 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными ---
Комментарий от тимлида №1
Методологически проверка гипотез проведена верно и наглядный вывод результатов
Мы провели три теста, только в product_page отвергли нулевую гипотезу, это было видно и по воронке
Нельзя брать во внимание результаты данного А/В теста, тк был допущен ряд нарушений, из основного:
Так же рекомендую если есть возможность не допускать участи пользователей в нескольких тестах.
Прошу чуть подробнее объяснить, что надо исправить.
PS Я не ругаюсь, честно не понимаю
Комментарий от тимлида №2
У меня нет привычки копировать. Каждый проект уникален, и всегда рассписываю по максимуму. Возможно просто не так поняли друг друга. Выше постарался описать максимально подробно
Комментарий от тимлида №1
Итоговый вывод завершает твое исследование. Представлены основные результаты полученные в ходе анализа, но самое главное - даны рекомендации по проведению АВ-теста. Это важное качество для аналитика. Развивай его и дальше
Не забудь, пожалуйста, подкорретировать вывод после правок
У тебя получилась очень сильная и хорошая работа. Здорово, что расчеты ты сопровождаешь иллюстрациями, а так же не забываешь про комментарии, твой проект интересно проверять.
Нужно поправить:
Даты
Пересеченные пользователи внутри теста
Количество событий на пользователя по группам
Количества событий по дням в каждой группе
Подправить выводы, после изменений
Если у тебя будут какие-то вопросы по моим комментариям - обязательно пиши! Буду ждать работу на повторное ревью :)
Комментарий от тимлида №2
От себя хочу порекомендовать тебе отличные источники про AB тестирование
В остальном всё чудно😊. Твой проект так и просится на github =)
Поздравляю с успешным завершением проекта 😊👍 И желаю успехов на SQL 😊